Skip to content

fix(watch): purge embeddings before nodes to stop FK crash in rebuildFile#1182

Merged
carlos-alm merged 5 commits into
mainfrom
fix/1176-watch-fk-embeddings
May 21, 2026
Merged

fix(watch): purge embeddings before nodes to stop FK crash in rebuildFile#1182
carlos-alm merged 5 commits into
mainfrom
fix/1176-watch-fk-embeddings

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • The watcher's rebuildFile rolled its own purgeAncillaryData helper that purged cfg_*, dataflow, complexity, node_metrics, and ast_nodes — but not embeddings. With codegraph embed populated and better-sqlite3's default PRAGMA foreign_keys = ON, the subsequent DELETE FROM nodes fired SQLITE_CONSTRAINT_FOREIGNKEY (embeddings.node_idnodes.id) and killed the long-running watcher process.
  • Switch rebuildFile to the shared purgeFileData helper already used by the main builder — it deletes from all child tables (including embeddings) in the correct FK-safe order, then edges, then nodes. Drop the now-unused deleteEdgesForFile / deleteNodes / countEdgesForFile plumbing from IncrementalStmts and the watcher's statement prep.
  • Wrap per-file rebuildFile calls in processPendingFiles with try/catch so any future SQLite/parse/filesystem error logs and skips instead of crashing the watcher. The watcher is a long-running session — one bad file should not bring it down.

Test plan

  • New regression test tests/integration/watcher-fk-embeddings.test.ts reproduces the failure (seeds an embeddings row, enables PRAGMA foreign_keys = ON, calls rebuildFile) and asserts no FK violation + that the embedding row is purged alongside the rebuilt file's nodes.
  • Existing parity test tests/integration/watcher-rebuild.test.ts still passes with the slimmer IncrementalStmts shape.
  • Full test suite: npm test — 2732 passed, 24 skipped, 0 failed.
  • Lint: npm run lint — clean.

Closes #1176

…File

The watcher's `rebuildFile` had its own `purgeAncillaryData` helper that
missed the `embeddings` table. With `codegraph embed` populated and
better-sqlite3's default `foreign_keys = ON`, the next `deleteNodes`
fired `SQLITE_CONSTRAINT_FOREIGNKEY` (embeddings.node_id → nodes.id)
and killed the watcher process.

Switch to the shared `purgeFileData` helper used by the main builder —
it purges embeddings, cfg_*, dataflow, complexity, node_metrics,
ast_nodes, edges, and nodes in the correct order — and drop the now
unused stmt prep. Wrap per-file `rebuildFile` calls in try/catch so
any future SQLite or parse error logs+skips instead of crashing the
long-running watcher.

Closes #1176
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Claude finished @carlos-alm's task —— View job


Reviewing PR #1182: Foreign key constraint fix in watcher

Todo List

  • Read and understand the changed files
  • Phase 1: Root cause analysis
  • Phase 2: Configuration change detection
  • Phase 3: Test integrity verification
  • Phase 4: Code quality review
  • Phase 5: Backlog compliance assessment
  • Provide final structured assessment

Phase 1: Root Cause Analysis ✓

Problem Being Solved: The watcher's rebuildFile function had a custom purgeAncillaryData helper that deleted from several tables (cfg_*, dataflow, complexity, node_metrics, ast_nodes) but NOT the embeddings table. With better-sqlite3's default PRAGMA foreign_keys = ON, the subsequent DELETE FROM nodes would trigger a foreign key constraint violation because embeddings.node_id references nodes.id.

Why This Approach: The fix correctly switches from the watcher's custom purge logic to the shared purgeFileData helper that's already used by the main builder. This helper properly deletes from all child tables (including embeddings) in the correct FK-safe order: dependent tables first, then edges, then nodes.

Risk Assessment: Low risk. This is a defensive fix that prevents crashes and uses existing, well-tested purge logic rather than duplicating/maintaining separate deletion code.

Phase 2: Configuration Change Detection ✓

No configuration changes detected. This PR makes no changes to:

  • Biome/linting rules
  • Test timeouts or coverage thresholds
  • Build configuration
  • TypeScript settings
  • Any rule relaxations

Phase 3: Test Integrity Verification ✓

New Regression Test: watcher-fk-embeddings.test.ts directly reproduces the FK violation:

  • ✅ Sets up embeddings table with FK constraint
  • ✅ Seeds an embedding row referencing a node from target file
  • ✅ Enables PRAGMA foreign_keys = ON (matches watcher behavior)
  • ✅ Calls rebuildFile and asserts no FK violation occurs
  • ✅ Verifies embedding is properly purged alongside nodes

Existing Tests: watcher-rebuild.test.ts still passes with slimmer IncrementalStmts interface (removed unused statements).

Test Quality: High-quality regression test that directly reproduces the user-reported failure scenario.

Phase 4: Code Quality Review ✅

Excellent Cleanup:

  • DRY Principle: Removes 35 lines of duplicated purge logic and consolidates to shared purgeFileData helper
  • Completeness: The old purgeAncillaryData was missing embeddings table cleanup - shared helper includes all tables
  • Safety: purgeFileData uses correct FK-safe deletion order and proper error handling
  • Interface Simplification: Removes unused deleteEdgesForFile/deleteNodes from IncrementalStmts
  • Error Handling: Adds per-file try/catch in processPendingFiles (lines 70-79) to prevent watcher crashes

Code Quality: Very clean implementation with excellent comments explaining the FK ordering requirement and referencing issue #1176.

No Security/Performance Concerns: Uses existing, vetted database operations. No command injection, SQL injection, or performance regressions.

Phase 5: Backlog Compliance Assessment ✅

Zero-dep: ✓ — No new runtime dependencies added. Uses existing SQLite operations.

Foundation-aligned: ✓ — Fully compliant with FOUNDATION.md principles:

  • No cloud API calls
  • No external dependencies
  • Pure defensive fix (doesn't generate code or make decisions)
  • Maintains programmatic API contract

Problem-fit: 4/5 — Directly prevents watcher crashes that would disrupt AI agents during long-running sessions. While not directly addressing "lost AI" token waste, it ensures the core dependency tracking infrastructure remains stable and available.

Breaking: No — This is a pure bug fix with no API changes. The IncrementalStmts interface change only removes unused properties, maintaining backward compatibility.

Tier: 1 (Zero-dep ✓ + Foundation-aligned ✓)


Root Cause Analysis

Problem Being Solved: Foreign key constraint violation crashes in watcher when embeddings exist
Why This Approach: Switch to shared, FK-safe purgeFileData helper instead of custom purge logic missing embeddings
Risk Assessment: Low risk - defensive fix using existing, well-tested database operations

Backlog Compliance

  • Zero-dep: ✓ — No new dependencies
  • Foundation-aligned: ✓ — Pure defensive fix, no principle violations
  • Problem-fit: 4 — Ensures core infrastructure stability for AI agents
  • Breaking: No — Bug fix with no API changes
  • Tier: 1

Critical Concerns

None identified. This is a well-executed fix with excellent test coverage.

Final Recommendation

  • Rating: ⭐⭐⭐⭐⭐ (5/5)
  • Action: APPROVE
  • Reasoning: Excellent bug fix that solves a real crash issue, includes perfect regression test, improves code quality by removing duplication, and follows all best practices. The FK constraint issue was a genuine bug that needed fixing, not a symptom of deeper problems.

@greptile-apps

greptile-apps Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a foreign-key crash in the watcher's rebuildFile path by replacing the hand-rolled purgeAncillaryData helper (which omitted embeddings) with the shared purgeFileData utility that deletes all child tables — including embeddings — in FK-safe order before removing nodes. It also wraps each per-file rebuild in a try/catch so a single bad file no longer kills the long-running watcher process.

  • FK fix: purgeAncillaryData in incremental.ts is dropped; purgeFileData(db, relPath, { purgeHashes: false }) is called instead, covering embeddings, cfg_*, dataflow, complexity, node_metrics, and ast_nodes before edges and nodes.
  • Resilience: processPendingFiles in watcher.ts now wraps each rebuildFile call in a try/catch, logging a warning and continuing on error instead of propagating.
  • Test coverage: A new regression test (watcher-fk-embeddings.test.ts) seeds an embeddings row, enables foreign_keys = ON, calls rebuildFile, and asserts no exception is thrown and the row is fully removed.

Confidence Score: 5/5

Safe to merge — the fix is a well-scoped replacement of a local purge helper with the already-battle-tested shared utility, and the new regression test directly reproduces the crash scenario.

All changed code follows existing patterns, the FK ordering in purgeFileData is already used by the main builder, the try/catch in processPendingFiles is straightforward defensive programming, and the regression test validates the exact failure path that motivated the fix. No logic errors or new failure modes were found.

No files require special attention.

Important Files Changed

Filename Overview
src/domain/graph/builder/incremental.ts Replaces local purgeAncillaryData + deleteEdgesForFile + deleteNodes with a single purgeFileData call; removes now-unused deleteEdgesForFile/deleteNodes slots from IncrementalStmts. Correct and well-commented.
src/domain/graph/watcher.ts Cleans up statement prep (removes deleted fields), adds warn import, and wraps each rebuildFile call in a try/catch with proper instanceof Error narrowing. No issues found.
tests/integration/watcher-fk-embeddings.test.ts New regression test seeding an embeddings row, running rebuildFile with FK enforcement on, and asserting the row is fully purged using a total-count query (not the flawed JOIN check from a prior review). Covers the reported failure path.
tests/integration/watcher-rebuild.test.ts Removes the now-deleted deleteNodes, deleteEdgesForFile, and countEdgesForFile fields from the makeStmts helper; parity test continues to exercise the slimmer IncrementalStmts shape.

Sequence Diagram

sequenceDiagram
    participant FS as File System (watcher)
    participant PP as processPendingFiles
    participant RF as rebuildFile
    participant PFD as purgeFileData
    participant DB as SQLite DB

    FS->>PP: file change detected
    loop for each changed file
        PP->>RF: rebuildFile(db, rootDir, filePath, stmts, ...)
        activate RF
        RF->>DB: findReverseDeps (read edges before purge)
        RF->>PFD: "purgeFileData(db, relPath, { purgeHashes: false })"
        activate PFD
        PFD->>DB: DELETE FROM embeddings WHERE node_id IN (nodes for file)
        PFD->>DB: DELETE FROM cfg_edges / cfg_blocks / dataflow / complexity / node_metrics / ast_nodes
        PFD->>DB: DELETE FROM edges (source/target in file)
        PFD->>DB: "DELETE FROM nodes WHERE file = ?"
        deactivate PFD
        RF->>RF: "parse file, insert new nodes & edges"
        RF-->>PP: RebuildResult
        deactivate RF
        Note over PP: catch(err) → warn + debug, skip file
    end
    PP->>DB: writeJournalAndChangeEvents (batch)
Loading

Reviews (5): Last reviewed commit: "Merge branch 'main' into fix/1176-watch-..." | Re-trigger Greptile

Comment thread src/domain/graph/watcher.ts Outdated
Comment on lines +77 to +78
warn(`Failed to rebuild ${relPath}: ${(err as Error).message} — skipping`);
debug((err as Error).stack ?? String(err));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 If err is a non-Error value (e.g., a plain string or null thrown by a third-party dependency), (err as Error).message evaluates to undefined, so the logged warning reads "Failed to rebuild …: undefined — skipping", which is hard to diagnose. Narrowing with instanceof produces a safe message in all cases.

Suggested change
warn(`Failed to rebuild ${relPath}: ${(err as Error).message} — skipping`);
debug((err as Error).stack ?? String(err));
const message = err instanceof Error ? err.message : String(err);
warn(`Failed to rebuild ${relPath}: ${message} — skipping`);
debug(err instanceof Error ? (err.stack ?? message) : String(err));

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d2db316 — replaced (err as Error).message with instanceof Error narrowing so a non-Error throw logs a usable message instead of "undefined".

Comment on lines +121 to +128
const remaining = db
.prepare(
`SELECT COUNT(*) AS c FROM embeddings e
JOIN nodes n ON e.node_id = n.id
WHERE n.file = ?`,
)
.get('shared/constants.js') as { c: number };
expect(remaining.c).toBe(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Secondary assertion does not verify the seeded row was deleted

The query JOIN nodes n ON e.node_id = n.id WHERE n.file = ? counts embeddings that reference currently existing nodes for shared/constants.js. After rebuildFile the old node IDs are gone, so even if the embedding row were left as an orphan the JOIN would find nothing and the count would still be 0. A more reliable check is to count all rows in embeddings (there was exactly one seeded row) or use WHERE node_id = ? with the known seeded ID, so the assertion actually fails if the row survives as an orphan.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d2db316 — switched the assertion to SELECT COUNT(*) FROM embeddings (exactly one row was seeded), so it now catches the orphan-row regression the JOIN check missed.

…1182)

The watcher's local getNodeId.get used a typed parameter list
(name, kind, file, line) but IncrementalStmts declares
get: (...params: unknown[]). TS2322 rejected the narrower form,
which broke the build job and all downstream CI checks.

Switch to (...params: unknown[]) and destructure inside the body
so the implementation matches the interface contract.
Addresses Greptile P2 review feedback:

- src/domain/graph/watcher.ts: replace `(err as Error).message` with
  `instanceof Error` narrowing so a non-Error throw (plain string,
  null, or any value from a third-party dependency) logs a usable
  message instead of "undefined".

- tests/integration/watcher-fk-embeddings.test.ts: count all rows in
  `embeddings` directly instead of joining on `nodes.file`. The
  JOIN-based check would pass even if the seeded row survived as an
  orphan with a dangling node_id; counting the table itself catches
  that regression.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

3 functions changed7 callers affected across 2 files

  • rebuildFile in src/domain/graph/builder/incremental.ts:497 (4 transitive callers)
  • prepareWatcherStatements in src/domain/graph/watcher.ts:18 (3 transitive callers)
  • processPendingFiles in src/domain/graph/watcher.ts:58 (4 transitive callers)

@carlos-alm carlos-alm merged commit f5e8c5a into main May 21, 2026
21 checks passed
@carlos-alm carlos-alm deleted the fix/1176-watch-fk-embeddings branch May 21, 2026 06:12
@github-actions github-actions Bot locked and limited conversation to collaborators May 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: watch mode crashes with FOREIGN KEY constraint failed in rebuildFile

1 participant